# forward-compat boilerplate
from __future__ import absolute_import
from __future__ import with_statement
__metaclass__ = type

import time
import urllib2
import datetime

from xml.etree import cElementTree as etree

import sqlalchemy as sql
import sqlalchemy.orm as orm
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm.collections import column_mapped_collection as coldict

from fenton import types
from fenton import logging
from fenton import security
from fenton import timestamp

# Crowd directory: there is only one
CROWD_DIRECTORY_ID = 32769
CROWD_COOKIE = 'crowd.token_key'




def initialize(app):
    config = app.config
    engine = sql.engine_from_config(config, prefix='crowd.db.')
    app.get_crowd_db = orm.sessionmaker(bind=engine, autoflush=False)
    config = dict((k.replace('crowd.', ''), config[k])
                  for k in config
                  if k.startswith('crowd.')
                  and not k.startswith('crowd.db.'))
    app.crowd_client = CrowdClient(**config)
    CrowdUser.tz = app.tz
    Principal.tz = app.tz


def declarative(**kw):
    from sqlalchemy.ext.declarative import declarative_base
    return lambda C: declarative_base(cls=C, **kw)


def synchronize(cx):
    keep_users = ['crowd-admin']
    keep_groups = ['crowd-administrators']

    db = cx.crowd_db
    users_left = set(db.query(Principal).all())
    groups_left = set(db.query(Group).all())

    for master in cx.app.auth.iter_users():
        cu = Principal._ensure(db, master.username)
        cu.synchronize(master, None)
        users_left.discard(cu)
        groups_left.difference_update(cu.groups)

    for u in users_left:
        if u.username not in keep_users:
            logging.log.warn('deleting user %s', u.username)
            db.delete(u)

    for g in groups_left:
        if g.group_name not in keep_groups:
            logging.log.warn('deleting group %s', g.group_name)
            db.delete(g)


def get_authenticated_user(context, username, password):
    p = Principal._get(context.crowd_db, username)
    if not p:
        raise security.NoUser
    if p.active != 'T':
        raise security.BadUser
    if password is None or p.authenticate(password):
        return CrowdUser(p)
    raise security.LoginFailed


def get_synchronized_user(context, master, password):
    p = Principal._ensure(context.crowd_db, master.username)
    p.synchronize(master, password)
    p.set_authenticate_stamp()
    return CrowdUser(p)


def qn(ns, tag):
    return '{%s}%s' % (ns, tag)


def qe(ns, name, parent=None, **attrs):
    name = qn(ns, name)
    if parent is not None:
        return etree.SubElement(parent, name, **attrs)
    else:
        return etree.Element(name, **attrs)




class SoapError(Exception):
    pass




class SoapService:

    ns_soap = 'http://schemas.xmlsoap.org/soap/envelope/'

    def __init__(self, url, debug=False):
        self.url = url
        self.debug = debug

    def el(self, *args, **kw):
        return qe(self.ns_soap, *args, **kw)

    def send(self, rq, msg):
        if self.debug:
            logging.log.debug('SEND:\n%s' % msg)
        try:
            response = urllib2.urlopen(rq, msg)
        except urllib2.HTTPError, e:
            if e.code == 500 and e.headers.subtype == 'xml':
                msg = e.read()
            else:
                msg = str(e)
            raise SoapError(msg)

        xml = response.read()
        if self.debug:
            logging.log.debug('RECV:\n%s' % xml)
        if not response.headers.subtype == 'xml':
            raise SoapError('The response was not XML')
        return xml

    def call(self, action, payload):
        rq = urllib2.Request(self.url)
        rq.add_header('SOAPAction', action)
        rq.add_header('Content-Type', 'text/xml')
        envelope = self.el('Envelope')
        body = self.el('Body', envelope)
        body.append(payload)
        response = self.send(rq, etree.tostring(envelope))
        if not response:
            raise SoapError('Empty response')
        return etree.fromstring(response).find(body.tag)[0]




class CrowdClient:

    ns_server = 'urn:SecurityServer'
    ns_auth = 'http://authentication.integration.crowd.atlassian.com'
    ns_xsi = 'http://www.w3.org/1999/XMLSchema-instance'

    def __init__(self, url, appname, appkey,
                 cookie_name=CROWD_COOKIE,
                 cookie_path='/',
                 cookie_domain=None,
                 cookie_secure=False,
                 debug=False):
        self.appname = appname
        self.appkey = appkey
        self.cookie_name = cookie_name
        self.cookie_path = cookie_path
        self.cookie_domain = cookie_domain
        self.cookie_secure = cookie_secure
        self.service = SoapService(url, debug)

    def f_el(self, name, parent=None, **attrs):
        return qe(self.ns_server, name, parent, **attrs)

    def v_el(self, name, parent=None, **attrs):
        return qe(self.ns_auth, name, parent, **attrs)

    def _authenticateApplication(self):
        action = 'authenticateApplication'
        request = self.f_el(action)

        cred = self.v_el('credential')
        self.v_el('credential', cred).text = self.appkey
        self.v_el('encryptedCredential', cred).text = 'false'

        name = self.v_el('name')
        name.text = self.appname

        nil = {qn(self.ns_xsi, 'nil'): 'true'}
        vf = self.v_el('validationFactors', **nil)

        arg0 = self.f_el('in0', request)
        for x in [cred, name, vf]:
            arg0.append(x)

        response = self.service.call(action, request)
        return list(response.getiterator(qn(self.ns_server, 'out')))[0]

    def authenticatePrincipal(self, username, password, user_agent=None, remote_addr=None):
        action = 'authenticatePrincipal'
        msg = self.f_el(action)

        appcred = self._authenticateApplication()

        arg0 = self.f_el('in0', msg)
        arg0.append(appcred[0])
        arg0.append(appcred[1])

        arg1 = self.f_el('in1', msg)

        self.v_el('application', arg1).text = self.appname
        self.v_el('name', arg1).text = username
        cred = self.v_el('credential', arg1)
        self.v_el('credential', cred).text = password

        vfs = self.v_el('validationFactors', arg1)

        vf = self.v_el('ValidationFactor', vfs)
        self.v_el('name', vf).text = 'User-Agent'
        self.v_el('value', vf).text = user_agent

        vf = self.v_el('ValidationFactor', vfs)
        self.v_el('name', vf).text = 'remote_address'
        self.v_el('value', vf).text = remote_addr

        vf = self.v_el('ValidationFactor', vfs)
        self.v_el('name', vf).text = 'X-Forwarded-For'
        self.v_el('value', vf).text = ''

        response = self.service.call(action, msg)
        return list(response.getiterator(qn(self.ns_server, 'out')))[0].text

    def createPrincipalToken(self, username, user_agent=None, remote_addr=None):
        action = 'createPrincipalToken'
        msg = self.f_el(action)

        appcred = self._authenticateApplication()

        arg0 = self.f_el('in0', msg)
        arg0.append(appcred[0])
        arg0.append(appcred[1])

        arg1 = self.f_el('in1', msg)
        arg1.text = username

        arg2 = self.f_el('in2', msg)

        vf = self.v_el('ValidationFactor', arg2)
        self.v_el('name', vf).text = 'User-Agent'
        self.v_el('value', vf).text = user_agent or ''

        vf = self.v_el('ValidationFactor', arg2)
        self.v_el('name', vf).text = 'remote_address'
        self.v_el('value', vf).text = remote_addr or ''

        vf = self.v_el('ValidationFactor', arg2)
        self.v_el('name', vf).text = 'X-Forwarded-For'
        self.v_el('value', vf).text = ''

        response = self.service.call(action, msg)
        return list(response.getiterator(qn(self.ns_server, 'out')))[0].text

    def isValidPrincipalToken(self, token, user_agent=None, remote_addr=None):
        action = 'isValidPrincipalToken'
        msg = self.f_el(action)

        appcred = self._authenticateApplication()

        arg0 = self.f_el('in0', msg)
        arg0.append(appcred[0])
        arg0.append(appcred[1])

        arg1 = self.f_el('in1', msg)
        arg1.text = token

        arg2 = self.f_el('in2', msg)

        vf = self.v_el('ValidationFactor', arg2)
        self.v_el('name', vf).text = 'User-Agent'
        self.v_el('value', vf).text = user_agent

        vf = self.v_el('ValidationFactor', arg2)
        self.v_el('name', vf).text = 'remote_address'
        self.v_el('value', vf).text = remote_addr

        vf = self.v_el('ValidationFactor', arg2)
        self.v_el('name', vf).text = 'X-Forwarded-For'
        self.v_el('value', vf).text = ''

        response = self.service.call(action, msg)
        text = list(response.getiterator(qn(self.ns_server, 'out')))[0].text
        return text == 'true'

    def invalidatePrincipalToken(self, token):
        action = 'invalidatePrincipalToken'
        msg = self.f_el(action)

        appcred = self._authenticateApplication()

        arg0 = self.f_el('in0', msg)
        arg0.append(appcred[0])
        arg0.append(appcred[1])

        arg1 = self.f_el('in1', msg)
        arg1.text = token

        self.service.call(action, msg)

# end class CrowdClient




class NewClient:
    default_host = 'localhost:8095'
    path = '/crowd/rest/usermanagement/1/'
    def __init__(self, appname, appkey, host=None, debug=False):
        self.host = host or self.default_host
        self.auth = ('%s:%s' % (appname, appkey)).encode('base64').replace('\n', '')

    def createPrincipalToken(self, username, user_agent=None, remote_addr=None):
        pass

    def isValidPrincipalToken(self, token, user_agent=None, remote_addr=None):
        pass

    def invalidatePrincipalToken(self, token):
        pass

    def request(self, method, path, data=None):

        import httplib
        h = httplib.HTTPConnection(self.host)
        headers = {
            'Authorization': 'Basic ' + self.auth,
            'Accept': 'application/json',
            'Connection': 'close'
        }
        if data:
            headers['Content-Type'] = 'application/json'
            data = types.json.dumps(data)

        try:
            h.request(method, self.path + path, data, headers)
            r = h.getresponse()
        except socket.error, e:
            raise URLError(e)

        rsp = r.read()
        if 200 <= r.status <= 299:
            return types.json.loads(rsp)
        raise SoapError(rsp)




class CrowdUser(security.User):
    username = name = groups = tz = None

    def __init__(self, user):
        self.username = user.username
        self.name = user.display_name
        self.mail = user.email_address
        self.groups = set(user.groupnames)
        self.password_ttl = user.password_ttl
        self.password_stamp = user.password_stamp
        self.touch()

    def __repr__(self):
        return 'CrowdUser(%s)' % self.username

    def password_change_due(self, grace=None):
        if not grace:
            return False
        return self.password_ttl and self.password_ttl < grace

    @property
    def password_age(self):
        if not self.password_stamp:
            return None
        now = timestamp.localnow(self.tz)
        return now - self.password_stamp

    def authenticate(self, request):
        app = request.app
        if not app.config.get('crowd_single_signon'):
            return

        cc = app.crowd_client
        ua = request.user_agent
        ra = request.remote_addr
        token = cc.createPrincipalToken(self.username, ua, ra)
        self.crowd_token = token
        response = getattr(request, 'response', None)
        if response:
            response.set_cookie(cc.cookie_name, token,
                                domain=cc.cookie_domain,
                                path=cc.cookie_path,
                                secure=cc.cookie_secure,
                                httponly=True)

    def unauthenticate(self, request):
        cc = request.app.crowd_client
        token = request.cookies.get(cc.cookie_name)
        if not token:
            return
        cc.invalidatePrincipalToken(token)
        response = getattr(request, 'response', None)
        if response:
            response.delete_cookie(cc.cookie_name, path='/')

    def touch(self):
        self._authenticated_ttl = int(time.time())

    def verify_authenticated(self, context):
        if not context.request.app.config.get('crowd_single_signon'):
            return True
        now = int(time.time())
        app = context.app
        cc = app.crowd_client
        previous = self._authenticated_ttl

        ttl = int(app.config.get('security.verify.ttl') or 0)
        if now - previous < ttl:
            return True
        token = context.request.cookies.get(cc.cookie_name)
        if not token:
            raise security.NotAuthenticated
        u = context.request.user_agent
        r = context.request.remote_addr
        if not cc.isValidPrincipalToken(token, u, r):
            context.request.user = security.ANONYMOUS
            raise security.NotAuthenticated
        self.touch()
        return True




@declarative()
class CrowdObject:

    @classmethod
    def _get(Class, db, name):
        q = db.query(Class).filter_by(directory_id=CROWD_DIRECTORY_ID)
        return q.filter(getattr(Class, Class.key) == name.lower()).first()

    @classmethod
    def _ensure(Class, db, name):
        x =  Class._get(db, name)
        if x is None:
            logging.log.info('creating %s for %s', Class.__name__, name)
            x = Class(name)
            db.add(x)
        return x




class Attribute(CrowdObject):
    __tablename__ = 'cwd_user_attribute'
    id = sql.Column(sql.Integer(), primary_key=True)
    attribute_name = sql.Column(sql.String(length=255), nullable=False)
    attribute_value = sql.Column(sql.String(length=255))
    attribute_lower_value = sql.Column(sql.String(length=255))
    user_id = sql.Column(sql.Integer(),
                         sql.ForeignKey('cwd_user.id'),
                         nullable=False)
    directory_id = sql.Column(sql.Integer(), nullable=False)

    def __init__(self, name, value):
        self.attribute_name = name
        self.attribute_value = value
        self.attribute_lower_value = value and str(value).lower() or None
        self.directory_id = CROWD_DIRECTORY_ID

    def __repr__(self):
        return 'crowd.Attribute(%s=%s)' % (self.attribute_name, self.attribute_value)




class Principal(CrowdObject):
    tz = None
    key = 'lower_user_name'
    __tablename__ = 'cwd_user'
    id = sql.Column(sql.Integer(), primary_key=True)
    user_name = sql.Column(sql.String(length=255), nullable=False)
    lower_user_name = sql.Column(sql.String(length=255), nullable=False)
    active = sql.Column(sql.CHAR(1), nullable=False)
    created_date = sql.Column(sql.DateTime(timezone=False), nullable=False)
    updated_date = sql.Column(sql.DateTime(timezone=False), nullable=False)
    first_name = sql.Column(sql.String(length=255))
    lower_first_name = sql.Column(sql.String(length=255))
    last_name = sql.Column(sql.String(length=255))
    lower_last_name = sql.Column(sql.String(length=255))
    display_name = sql.Column(sql.String(length=255))
    lower_display_name = sql.Column(sql.String(length=255))
    email_address = sql.Column(sql.String(length=255))
    lower_email_address = sql.Column(sql.String(length=255))

    directory_id = sql.Column(sql.Integer(), nullable=False)

    credential = sql.Column(sql.String(length=255))

    memberships = orm.relation('Membership',
                               cascade='all,delete-orphan',
                               backref='user')

    _attributes = orm.relation('Attribute',
                               cascade='all,delete-orphan',
                               collection_class=coldict(Attribute.attribute_name))

    attributes = association_proxy('_attributes', 'attribute_value')

    groups = association_proxy('memberships', 'group')
    groupnames = property(lambda x:[g.group_name for g in x.groups])

    @property
    def password_stamp(self):
        stamp = self.attributes.get('passwordLastChanged')
        if stamp:
            return datetime.datetime.fromtimestamp(int(stamp)/1000.0, self.tz)

    password_ttl = None

    username = property(lambda x:x.user_name)

    cred_prefix = '{SSHA}'

    def __init__(self, username, clearpass=None):
        self.directory_id = CROWD_DIRECTORY_ID
        self.user_name = username
        self.lower_user_name = username.lower()
        self.created_date = datetime.datetime.now()
        self.updated_date = datetime.datetime.now()
        self.active = 'T'
        self.attributes['requiresPasswordChange'] = 'false'
        self.attributes['invalidPasswordAttempts'] = '0'

    def __repr__(self):
        return 'crowd.Principal(%s)' % self.user_name

    def set_credential(self, clearpass):
        self.index = 0
        if clearpass:
            hashed = security.mkhash(clearpass, impl=security.sha1impl)
            self.credential = self.cred_prefix + hashed
            self.set_authenticate_stamp()
        else:
            self.credential = None

    def compare_credential(self, clearpass):
        if self.credential and clearpass:
            cred = self.credential[len(self.cred_prefix):]
            return security.cmphash(clearpass, cred, impl=security.sha1impl)
        return False

    def authenticate(self, clearpass):
        if not self.credential:
            raise security.BadUser
        if not self.compare_credential(clearpass):
            raise security.BadPassword
        self.set_authenticate_stamp()
        return True

    def set_authenticate_stamp(self):
        self.attributes['lastAuthenticated'] = str(int(time.time()*1000))

    def synchronize(self, master, clearpass=None):
        if clearpass is not None and not self.compare_credential(clearpass):
            self.set_credential(clearpass)

        self.display_name = master.displayname
        self.first_name = x = master.givenname
        self.lower_first_name = x and x.lower()
        self.last_name = x = master.sn
        self.lower_last_name = x and x.lower()
        self.email_address = x = master.mail
        self.lower_email_address = x and x.lower()
        self.password_ttl = master.password_ttl
        if master.password_stamp:
            t = master.password_stamp
            t = int(time.mktime(t.timetuple()) * 1000 + t.microsecond / 1000.0)
            self.attributes['passwordLastChanged'] = str(t)
        self.active = master.disabled and 'F' or 'T'
        if master.groups:
            db = orm.object_session(self)
            new = set(Group._ensure(db, n) for n in set(master.groups))
            old = set(self.groups)
            self.memberships = [m for m in self.memberships
                                if m.group not in old - new]
            for g in new - old:
                self.memberships.append(Membership(g, self))

# end class Principal




class Group(CrowdObject):
    key = 'lower_group_name'
    __tablename__ = 'cwd_group'
    id = sql.Column(sql.Integer(), primary_key=True, nullable=False)
    group_name = sql.Column(sql.String(length=255), primary_key=True, nullable=False)
    lower_group_name = sql.Column(sql.String(length=255), primary_key=True, nullable=False)
    active = sql.Column(sql.CHAR(1), nullable=False)
    is_local = sql.Column(sql.CHAR(1), nullable=False)
    created_date = sql.Column(sql.DateTime(timezone=False), nullable=False)
    updated_date = sql.Column(sql.DateTime(timezone=False), nullable=False)
    description = sql.Column(sql.String(length=255))
    group_type = sql.Column(sql.String(length=32))
    directory_id = sql.Column(sql.Integer())

    memberships = orm.relation('Membership',
                               cascade='all,delete-orphan',
                               backref='group')

    members = property(lambda x:[m.user for m in x.memberships])

    def __init__(self, name):
        self.group_name = name
        self.lower_group_name = name.lower()
        self.directory_id = CROWD_DIRECTORY_ID
        self.active = 'T'
        self.is_local = 'F'
        self.created_date = datetime.datetime.now()
        self.updated_date = datetime.datetime.now()
        self.description = None
        self.group_type = 'GROUP'

    def __repr__(self):
        return 'crowd.Group(%s)' % self.group_name

# end class Group




class Membership(CrowdObject):
    __tablename__ = 'cwd_membership'
    id = sql.Column(sql.Integer(), primary_key=True)
    parent_id = sql.Column(sql.Integer())
    child_id = sql.Column(sql.Integer())
    membership_type = sql.Column(sql.String(length=32))
    group_type = sql.Column(sql.String(length=32))

    parent_name = sql.Column(sql.String(length=255), nullable=False)
    lower_parent_name = sql.Column(sql.String(length=255), nullable=False)
    child_name = sql.Column(sql.String(length=255), nullable=False)
    lower_child_name = sql.Column(sql.String(length=255), nullable=False)

    directory_id = sql.Column(sql.Integer(), nullable=False)

    def __init__(self, group, user):
        self.group = group
        self.user = user
        self.parent_name = group.group_name
        self.lower_parent_name = group.lower_group_name
        self.child_name = user.user_name
        self.lower_child_name = user.lower_user_name
        self.directory_id = CROWD_DIRECTORY_ID
        self.group_type = 'GROUP'
        self.membership_type = 'GROUP_USER'

    __table_args__ = (
        sql.ForeignKeyConstraint(['directory_id', 'lower_parent_name'],
                                 ['cwd_group.directory_id', 'cwd_group.lower_group_name']),
        sql.ForeignKeyConstraint(['directory_id', 'lower_child_name'],
                                 ['cwd_user.directory_id', 'cwd_user.lower_user_name']),
    )

# end class Membership




